agora inbox for [email protected]help / color / mirror / Atom feed
[PATCH v16 4/8] Row pattern recognition patch (planner). 1062+ messages / 2 participants [nested] [flat]
* [PATCH v16 4/8] Row pattern recognition patch (planner). @ 2024-04-12 06:49 Tatsuo Ishii <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Tatsuo Ishii @ 2024-04-12 06:49 UTC (permalink / raw) --- src/backend/optimizer/plan/createplan.c | 24 +++++++++++++++----- src/backend/optimizer/plan/planner.c | 3 +++ src/backend/optimizer/plan/setrefs.c | 27 ++++++++++++++++++++++- src/backend/optimizer/prep/prepjointree.c | 4 ++++ src/include/nodes/plannodes.h | 19 ++++++++++++++++ 5 files changed, 71 insertions(+), 6 deletions(-) diff --git a/src/backend/optimizer/plan/createplan.c b/src/backend/optimizer/plan/createplan.c index 3b77886567..c8d96be7d7 100644 --- a/src/backend/optimizer/plan/createplan.c +++ b/src/backend/optimizer/plan/createplan.c @@ -287,9 +287,11 @@ static WindowAgg *make_windowagg(List *tlist, Index winref, int ordNumCols, AttrNumber *ordColIdx, Oid *ordOperators, Oid *ordCollations, int frameOptions, Node *startOffset, Node *endOffset, Oid startInRangeFunc, Oid endInRangeFunc, - Oid inRangeColl, bool inRangeAsc, bool inRangeNullsFirst, - List *runCondition, List *qual, bool topWindow, - Plan *lefttree); + Oid inRangeColl, bool inRangeAsc, bool inRangeNullsFirst, List *runCondition, + RPSkipTo rpSkipTo, List *patternVariable, List *patternRegexp, + List *defineClause, + List *defineInitial, + List *qual, bool topWindow, Plan *lefttree); static Group *make_group(List *tlist, List *qual, int numGroupCols, AttrNumber *grpColIdx, Oid *grpOperators, Oid *grpCollations, Plan *lefttree); @@ -2700,6 +2702,11 @@ create_windowagg_plan(PlannerInfo *root, WindowAggPath *best_path) wc->inRangeAsc, wc->inRangeNullsFirst, wc->runCondition, + wc->rpSkipTo, + wc->patternVariable, + wc->patternRegexp, + wc->defineClause, + wc->defineInitial, best_path->qual, best_path->topwindow, subplan); @@ -6629,8 +6636,10 @@ make_windowagg(List *tlist, Index winref, int ordNumCols, AttrNumber *ordColIdx, Oid *ordOperators, Oid *ordCollations, int frameOptions, Node *startOffset, Node *endOffset, Oid startInRangeFunc, Oid endInRangeFunc, - Oid inRangeColl, bool inRangeAsc, bool inRangeNullsFirst, - List *runCondition, List *qual, bool topWindow, Plan *lefttree) + Oid inRangeColl, bool inRangeAsc, bool inRangeNullsFirst, List *runCondition, + RPSkipTo rpSkipTo, List *patternVariable, List *patternRegexp, List *defineClause, + List *defineInitial, + List *qual, bool topWindow, Plan *lefttree) { WindowAgg *node = makeNode(WindowAgg); Plan *plan = &node->plan; @@ -6656,6 +6665,11 @@ make_windowagg(List *tlist, Index winref, node->inRangeAsc = inRangeAsc; node->inRangeNullsFirst = inRangeNullsFirst; node->topWindow = topWindow; + node->rpSkipTo = rpSkipTo, + node->patternVariable = patternVariable; + node->patternRegexp = patternRegexp; + node->defineClause = defineClause; + node->defineInitial = defineInitial; plan->targetlist = tlist; plan->lefttree = lefttree; diff --git a/src/backend/optimizer/plan/planner.c b/src/backend/optimizer/plan/planner.c index 5320da51a0..5eb66c709b 100644 --- a/src/backend/optimizer/plan/planner.c +++ b/src/backend/optimizer/plan/planner.c @@ -873,6 +873,9 @@ subquery_planner(PlannerGlobal *glob, Query *parse, PlannerInfo *parent_root, wc->runCondition = (List *) preprocess_expression(root, (Node *) wc->runCondition, EXPRKIND_TARGET); + wc->defineClause = (List *) preprocess_expression(root, + (Node *) wc->defineClause, + EXPRKIND_TARGET); } parse->limitOffset = preprocess_expression(root, parse->limitOffset, diff --git a/src/backend/optimizer/plan/setrefs.c b/src/backend/optimizer/plan/setrefs.c index 37abcb4701..cd0e7c57d8 100644 --- a/src/backend/optimizer/plan/setrefs.c +++ b/src/backend/optimizer/plan/setrefs.c @@ -210,7 +210,6 @@ static List *set_windowagg_runcondition_references(PlannerInfo *root, List *runcondition, Plan *plan); - /***************************************************************************** * * SUBPLAN REFERENCES @@ -2471,6 +2470,32 @@ set_upper_references(PlannerInfo *root, Plan *plan, int rtoffset) NRM_EQUAL, NUM_EXEC_QUAL(plan)); + /* + * Modifies an expression tree in each DEFINE clause so that all Var + * nodes's varno refers to OUTER_VAR. + */ + if (IsA(plan, WindowAgg)) + { + WindowAgg *wplan = (WindowAgg *) plan; + + if (wplan->defineClause != NIL) + { + foreach(l, wplan->defineClause) + { + TargetEntry *tle = (TargetEntry *) lfirst(l); + + tle->expr = (Expr *) + fix_upper_expr(root, + (Node *) tle->expr, + subplan_itlist, + OUTER_VAR, + rtoffset, + NRM_EQUAL, + NUM_EXEC_QUAL(plan)); + } + } + } + pfree(subplan_itlist); } diff --git a/src/backend/optimizer/prep/prepjointree.c b/src/backend/optimizer/prep/prepjointree.c index 4badb6ff58..1c34360c7c 100644 --- a/src/backend/optimizer/prep/prepjointree.c +++ b/src/backend/optimizer/prep/prepjointree.c @@ -2178,6 +2178,10 @@ perform_pullup_replace_vars(PlannerInfo *root, if (wc->runCondition != NIL) wc->runCondition = (List *) pullup_replace_vars((Node *) wc->runCondition, rvcontext); + + if (wc->defineClause != NIL) + wc->defineClause = (List *) + pullup_replace_vars((Node *) wc->defineClause, rvcontext); } if (parse->onConflict) { diff --git a/src/include/nodes/plannodes.h b/src/include/nodes/plannodes.h index e025679f89..90441f4d90 100644 --- a/src/include/nodes/plannodes.h +++ b/src/include/nodes/plannodes.h @@ -20,6 +20,7 @@ #include "lib/stringinfo.h" #include "nodes/bitmapset.h" #include "nodes/lockoptions.h" +#include "nodes/parsenodes.h" #include "nodes/primnodes.h" @@ -1098,6 +1099,24 @@ typedef struct WindowAgg /* nulls sort first for in_range tests? */ bool inRangeNullsFirst; + /* Row Pattern Recognition AFTER MACH SKIP clause */ + RPSkipTo rpSkipTo; /* Row Pattern Skip To type */ + + /* Row Pattern PATTERN variable name (list of String) */ + List *patternVariable; + + /* + * Row Pattern RPATTERN regular expression quantifier ('+' or ''. list of + * String) + */ + List *patternRegexp; + + /* Row Pattern DEFINE clause (list of TargetEntry) */ + List *defineClause; + + /* Row Pattern DEFINE variable initial names (list of String) */ + List *defineInitial; + /* * false for all apart from the WindowAgg that's closest to the root of * the plan -- 2.25.1 ----Next_Part(Fri_Apr_12_16_09_08_2024_262)-- Content-Type: Text/X-Patch; charset=us-ascii Content-Transfer-Encoding: 7bit Content-Disposition: inline; filename="v16-0005-Row-pattern-recognition-patch-executor.patch" ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index a758e1a..d08b122 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -108,6 +108,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg17.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 6cc2acb..7dfc311 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 81efebc..21f1362 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index c1f4ab5..92abe13 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg15.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index eedc5c8..c4d5cb1 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -113,6 +113,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 4bcd4e9..03fbd97 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 3e8929a..db2a21d 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc-- ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index 9b7187e..21d21ea 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -115,6 +115,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar, cxt.lastGeneratedObjectId = 1 + countVars(vars); cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenceOfErrors(&cxt) && !result) @@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index 8cf6ecf..0bbad25 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index acb508c..1d867d8 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg18.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries @ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]> 0 siblings, 0 replies; 1062+ messages in thread From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw) When a jsonpath expression contains multiple consecutive .** (jpiAny) accessors, each one triggers a full subtree traversal in executeAnyItem(). k consecutive .** operators can degrade performance to O(N^k) on a document with N nodes, even though a chain like $.**.**.** is redundant for existence semantics and equivalent to a single $.** with merged level bounds. This was reported as a performance problem when expressions such as $.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same query without the trailing .* completes in sub-millisecond time, while the form with redundant .** segments can run for many minutes. A similar pattern in strict mode can also provoke very large memory allocation attempts. Collapse consecutive jpiAny nodes at execution time for existence queries (@? and jsonb_path_exists), merging their level bounds and performing a single executeAnyItem() pass. Author: Andrey Rachitskiy <[email protected]> Reported-by: Andrey Rachitskiy <[email protected]> Backpatch-through: 15 --- src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++--- .../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++ src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c index ba9bbb6..74fc499 100644 --- a/src/backend/utils/adt/jsonpath_exec.c +++ b/src/backend/utils/adt/jsonpath_exec.c @@ -107,6 +107,8 @@ typedef struct JsonPathExecContext * ignored */ bool throwErrors; /* with "false" all suppressible errors are * suppressed */ + bool existsOnly; /* @? / jsonb_path_exists: may collapse + * redundant consecutive .** accessors */ bool useTz; } JsonPathExecContext; @@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt, JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found, uint32 level, uint32 first, uint32 last, bool ignoreStructuralErrors, bool unwrapNext); +static uint32 mergeAnyBound(uint32 a, uint32 b); static JsonPathBool executePredicate(JsonPathExecContext *cxt, JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg, JsonbValue *jb, bool unwrapRightArg, @@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors, cxt.lastGeneratedObjectId = vars ? 2 : 1; cxt.innermostArraySize = -1; cxt.throwErrors = throwErrors; + cxt.existsOnly = (result == NULL); cxt.useTz = useTz; if (jspStrictAbsenseOfErrors(&cxt) && !result) @@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, case jpiAny: { - bool hasNext = jspGetNext(jsp, &elem); + JsonPathItem elem; + bool hasNext; + uint32 first; + uint32 last; + + hasNext = jspGetNext(jsp, &elem); + first = jsp->content.anybounds.first; + last = jsp->content.anybounds.last; + + /* + * Consecutive .** accessors are redundant for existence + * queries and multiply traversal cost. + */ + if (cxt->existsOnly) + { + while (hasNext && elem.type == jpiAny) + { + first = mergeAnyBound(first, elem.content.anybounds.first); + last = mergeAnyBound(last, elem.content.anybounds.last); + hasNext = jspGetNext(&elem, &elem); + } + } /* first try without any intermediate steps */ - if (jsp->content.anybounds.first == 0) + if (first == 0) { bool savedIgnoreStructuralErrors; savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors; cxt->ignoreStructuralErrors = true; - res = executeNextItem(cxt, jsp, &elem, + res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL, jb, found, true); cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors; @@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp, (cxt, hasNext ? &elem : NULL, jb->val.binary.data, found, 1, - jsp->content.anybounds.first, - jsp->content.anybounds.last, + first, + last, true, jspAutoUnwrap(cxt)); break; } @@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp, return res; } + +/* + * Merge level bounds of two consecutive .** accessors. + */ +static uint32 +mergeAnyBound(uint32 a, uint32 b) +{ + if (a == PG_UINT32_MAX || b == PG_UINT32_MAX) + return PG_UINT32_MAX; + return a + b; +} + /* * Implementation of several jsonpath nodes: * - jpiAny (.** accessor), diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out index eafb421..5e1bd19 100644 --- a/src/test/regress/expected/jsonb_jsonpath.out +++ b/src/test/regress/expected/jsonb_jsonpath.out @@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; t (1 row) +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; + ?column? +---------- + t +(1 row) + +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; + ?column? +---------- + f +(1 row) + +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); + jsonb_path_exists +------------------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; + ?column? +---------- + t +(1 row) + +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + ?column? +---------- + t +(1 row) + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); jsonb_path_query ------------------ diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql index 8163fc6..4c62fe2 100644 --- a/src/test/regress/sql/jsonb_jsonpath.sql +++ b/src/test/regress/sql/jsonb_jsonpath.sql @@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)'; select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)'; +-- Redundant consecutive .** accessors are collapsed for existence queries +-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost. +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)'; +select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)'; +select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)'); +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'lax $.**.**.**.**'; +select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb + @? 'strict $.**.**.**'; + select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))'); select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))'); -- 2.47.3 --MP_/BlgiDE/t55y8Vb7V2uvNybc Content-Type: text/x-patch Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=0001-collapse-consecutive-jsonpath-any-pg16.patch ^ permalink raw reply [nested|flat] 1062+ messages in thread
end of thread, other threads:[~2026-06-18 20:30 UTC | newest] Thread overview: 1062+ messages (download: mbox mbox.gz follow: Atom feed) -- links below jump to the message on this page -- 2024-04-12 06:49 [PATCH v16 4/8] Row pattern recognition patch (planner). Tatsuo Ishii <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]> 2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
This inbox is served by agora; see mirroring instructions for how to clone and mirror all data and code used for this inbox